为什么需要异步编程?
在我们的程序中,类似下面这样的代码,是由计算机的处理器一次性执行完毕的。代码的运行速度也很大程度上取决于计算机处理器的速度。
1 | const sumTo = function(n) { |
但是,还有很多其他程序需要在处理器外面与其他设备交互。比如,有些程序可能需要通过计算机网络进行通信,或者从硬盘读取数据,而这些都比从内存读取代码慢得多了。
当这样的情形发生时,让处理器处于空闲状态是非常浪费资源的,因为可能有不少其他工作需要处理器在这时来处理。某种程度上来说,这取决于你的操作系统,它会切换处理器运行多个不同的程序。但是,当我们在运行单个程序,遇到等待网络请求的情形时,我们没法让处理器去做其他事情。
在同步编程的模型里,事情一件接着一件地执行。当调用一个性能开销很大的函数时,只有当函数内的操作都完成了才会返回。在这个函数运行期间,程序只能原地待命。
异步编程的模型允许多个事情在同一时间发生。当我们开始一项任务(比如从硬盘读取数据),我们的程序可以继续运行。当这个任务完成时,程序会通知我们并返回一个结果。
我们可以用一个小例子来对比同步编程和异步编程:一个程序从网络获取两个资源文件然后组合成需要的结果。
在一个同步的环境里,只有当网络请求全部完成时,请求函数才会返回。执行上面那个例子的最简单方式就是:一个接一个地发出网络请求。这样做的缺点是:第二个网络请求只有在第一个网络请求全部完成时才会开始。那么,完成上述例子的时间将是至少两项网络请求所需时间之和。
1 | // 先请求第一个资源文件 |
针对这类问题的解决方式,在一个同步的环境里就是增加额外的线程。线程是指另一个运行中的程序,它的执行可能通过操作系统与另外的程序交织在一起。鉴于大多数现代计算机都包含多个核心的处理器,多个线程可能同时由不同的处理器核心来运行。对于上面的小例子来说,增加的第二个线程可以用来发起第二个网络请求,接着让两个线程都等待返回的结构,然后两个线程实现同步,再组合成需要的结果。
两个重量级的 JavaScript 平台,浏览器和 NodeJS 都需要进行一些费时的异步操作,而不是依赖于多线程。鉴于多线程编程是众所周知的难,所以这通常被认为是个好事情。
回调 Callback
异步编程的其中一种方式是:当函数执行耗时较长的操作时,增加一个额外的参数,即回调函数。当这个耗时的操作开始,等待它结束并返回一个结果时,回调函数以这个结果为参数被调用。
举个例子,在 NodeJS 和浏览器环境中都已实现的 setTimeout
函数,等待一段时间(以毫秒为单位)后再调用回调函数。
1 | setTimeout(() => console.log('Tick'), 500) |
回调实例:加载脚本
一个典型的用例:动态加载一个脚本,并在脚本加载完成后使用该脚本。在浏览器的 Web API 中,像 Window
,XMLHttpRequest
,<script>
,<img>
等元素都部署了 onload
事件处理接口,当资源加载完成后被触发。这里以加载 <script>
元素的资源为例:
1 | const loadScript = function(src, callback) { |
回调地狱
有的时候,我们需要按照一定的顺序来执行一连串的异步任务。如果使用回调函数的方式来处理,就会形成嵌套的异步任务。假设我们现在要依次获取 3 个脚本资源,那么就会形成下面这样的代码:
1 | loadScript('one.js', (error, script) => { |
上面的代码即构成了回调金字塔,或者称回调地狱。这样的代码不仅不易阅读,后续维护成本也高。
Promise
为了解决回调地狱,Promise 诞生了。一个 Promise 的实例,是一个代表着最终会完成或者失败的异步操作的对象。新建一个 Promise 实例的语法如下:
1 | const promise = new Promise((resolve, reject) => { |
上面这种新建 Promise 实例的方式,通常只会在封装基于回调处理异步操作的陈旧代码时用到(绝大部分时候,我们是 Promise 实例的使用者)。后面会给出详细例子。
executor
传递给 new Promise
的参数是一个叫做 executor
的函数,它会在 promise
被创建的时候自动立即执行。executor
函数接受 2 个参数,resolve
函数 和 reject
函数,它们是由引擎预定义的,我们不必创建它们。我们应当在执行成功时调用 resolve
函数,执行失败时调用 reject
函数。
创建的 Promise 实例具备 2 个内部属性:
state
初始值为 pending,之后会转变为 fulfilled 或者 rejected。result
初始值为undefined
,之后会转变为结果或者错误对象
不可逆过程
每个 Promise 实例可能的状态有 3 种:初始状态 pending,完成状态 fulfilled 和 rejected。即一个 Promise 实例要么由 pending 变成 fulfilled,要么由 pending 变成 rejected,并且此过程不可逆,一旦到了完成状态就无法改变。如下图所示:

resolve 实例
1 | const promise = new Promise((resolve, reject) => { |
reject 实例
1 | const promise = new Promise((resolve, reject) => { |
resolve 与 reject 注意事项
resolve
函数或者 reject
函数其中任意一个只会被调用一次,后续的调用都将会忽略:
1 | const promise = new Promise((resolve, reject) => { |
resolve
函数或者 reject
函数都只接受一个参数,多余的参数将会忽略。
then
本质上,Promise 做的事情是,将一项耗费时间的任务(我们传入的 executor
函数)自动立即执行,然后在任务完成后将这项任务的结果保存到内部属性 result
。这个结果可能是我们传入 resolve
函数的任意值,也可能是我们传入 reject
函数的错误对象。并且这个结果一旦形成就不会再改变。
我们可以通过 Promise 实例的 then
方法来获取结果。它可能被成功解决,返回一个结果:
1 | const promise = new Promise((resolve, reject) => { |
也可能失败,返回错误对象:
1 | const promise = new Promise(function(resolve, reject) { |
链式调用
Promise 的 then
方法支持链式调用。注意下面代码中 then
方法内的返回值。
1 | new Promise(resolve => { |
thenable
事实上,then
方法只要返回一个部署了 then
方法的任意对象,引擎就会将它当做 Promise 的实例来看待。第三方库可以利用这一点来兼容原生的 Promise。
1 | class Thenable { |
catch
本质上是基于 then
实现的,只不过专注于错误处理。由于链式调用 then
方法可以传递结果和错误,所以最佳实践通常是这样:
1 | new Promise(resolve => { |
finally
与 try...catch...finally
类似,主要用于做一些清理扫尾的工作,略。
Promise 实例:加载脚本
使用 Promise 改写上面使用回调实现的加载脚本实例:
1 | const loadScript = function(src) { |
Promise 串行加载资源
链式调用 Promise 实例的 then
方法可以规避上面提到的回调地狱。使用 Promise 改写上面的按照顺序获取脚本资源的例子:
1 | // 串行获取资源 |
串行执行异步操作可以改写成更聪明简洁的写法:
1 | const asyncFn1 = x => x + 1 |
上面的代码等同于:
1 | Promise.resolve(1).then(asyncFn1).then(asyncFn2).then(asyncFn3)。 |
还可以进一步抽象出一个组合函数,这个组合函数以一组函数为参数,按照参数的顺序依次执行,上一个函数的输出结果作为下一个函数的输入参数。这通常被使用在函数式编程中:
1 | const applyAsync = (acc, val) => acc.then(val) |
Promise 实例:fetch
Promise 通常被用于网络请求。这里我们以 fetch 方法为例,从远程服务器获取用户数据 user.json
。fetch
方法的基本用法如下:
1 | const promise = fetch(url) // 返回一个 Promise 的实例 |
详细的例子如下:
1 | fetch('https://javascript.info/article/promise-chaining/user.json') |
response
还有一个 response.json()
方法,它会读取服务器的响应内容并将之解析为 JSON。很多时候使用它来代替 response.text()
会更加方便:
1 | fetch('https://javascript.info/article/promise-chaining/user.json') |
接着让我们进一步扩展上面的例子,比如使用获取的用户数据做点什么。这里我们用获取到的用户名,查询该用户的 GitHub 信息,然后在网页上显示用户的头像:
1 | fetch('https://javascript.info/article/promise-chaining/user.json') |
注意到上面代码中 (*)
这一行,用户头像显示 3 秒后将被移除。为了让代码更具有扩展性,即可以继续往下链式调用,同时传递数据,我们可以让第 4 个 then
方法返回一个新的 Promise 实例:
1 | fetch('https://javascript.info/article/promise-chaining/user.json') |
通常来说,最好让每一个异步操作都返回 Promise 的实例,这样的代码更具扩展性,即使我们现在不需要继续往下链式调用,可能将来会用到。
最后,让我们优化上面的代码,将部分代码抽象成可复用的函数:
1 | const loadJson = function(url) { |
Promise 错误处理
Promise 实例的错误可以沿着 then
方法往下链式传递,所以最佳实践通常是在最后统一处理错误,以上面的例子为例可以这样做:
1 | loadJson('https://javascript.info/article/promise-chaining/user.json') |
隐式的 try...catch
Promise 实例的 executor
函数和 then
函数内都存在一个隐式的 try...catch
。即如果出现错误,后续 .catch
会自动捕获所有错误:
1 | new Promise((resolve, reject) => { |
实际上相当于以下代码:
1 | new Promise((resolve, reject) => { |
在 then
方法内抛出错误,也是一样,会被传递到最近的 .catch
:
1 | new Promise((resolve, reject) => { |
不仅是抛出的错误,对于所有错误也是如此:
1 | new Promise((resolve, reject) => { |
实例
以上面的 fetch
方法请求用户数据为例,类似下面的错误处理方式,效果依然很不理想:
1 | fetch('no-such-user.json') // (*) |
最后的 .catch
方法捕获的错误非常宽泛,无法直观的看出究竟是哪里出了错误,以及进一步的错误信息。对于上面的代码而言,可能有如下错误:
- 请求
no-such-user.json
数据,服务器返回的响应是一个404
或者500
的错误提示页面。 - 成功拿到用户数据,但是请求 GitHub 用户信息接口的时候,返回的响应是一个
404
或者500
的错误提示页面。
我们可以增加一个步骤,检查 response.status
代表的 HTTP 状态码是否为 200
。如果不是则抛出一个定制的 HttpError
错误:
1 | class HttpError extends Error { |
再看一个针对特定错误进行处理的例子:
1 | const demoGithubUser = function() { |
上面的代码,注意 (*)
这一行,当没有查询到对应名字的 GitHub 用户时,弹出没有此用户的信息并让用户重新输入有效的 GitHub 用户名。而对于其他错误,重新抛出。
unhandled rejections
对于 Promise 内未处理的错误,比如 catch
重新抛出的错误,或者根本没有 catch
方法,大多数引擎都会追踪到这些未处理错误,并创建一个全局的错误。对于浏览器环境而言,我们可以通过监听全局事件 unhandledrejection
来访问错误并作出处理。NodeJS 环境也有类似的机制离开处理未处理的 Promise 错误。
1 | window.addEventListener('unhandledrejection', event => { |
Promise 静态方法
Promise 一共有以下 4 个静态方法:
Promise.resolve
Promise.reject
Promise.all
Promise.race
Promise.resolve
主要使用场景:将一个值封装为 Promise 的实例。举例,下面的代码实现的功能是:假如某个 url 的资源之前已经获取过,可以通过 then
方法直接返回资源。
1 | const loadCached = function (url) { |
Promise.reject
用于创建包含错误对象的 Promise 实例,很少用到。
Promise.all
并发执行异步操作,会等待所有异步任务完成,返回的结果是由各项异步任务返回结果组成的数组;如果其中任意一个异步任务出错,直接返回错误作为最终结果,其他异步任务的结果将被忽略。
基本用法:
1 | Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => { |
考虑如下代码:
1 | Promise.all([ |
then
方法返回的结果是一个数组,数组成员为传入 Promise.all
的 Promise 实例 resolve
的结果,顺序也与 Promise.all
的参数顺序一致,而与异步操作的时间无关。
一个常用的技巧是,将一组任务的数据映射为一个成员为 Promise 实例的数组,然后使用 Promise.all
来并发执行异步任务:
1 | let urls = [ |
实现容错的并发异步任务
Promise.all
方法本身不具备容错性,即一旦有一个异步任务错误便立即返回错误信息。但是,有时候我们希望的结果是这样的:返回一个数组,包含处理成功的异步任务结果和处理失败的异步任务的错误信息。为了实现这一点,需要使用 catch
捕获错误,让错误不被抛出,同时让错误继续往下传递:
1 | const urls = [ |
Promise.race
与 Promise.all
类似,不同点在于,Promise.race
不会等待所有异步任务完成,而是只要其中一个完成或者出错,就立即返回处理结果或者错误对象,忽略掉后续的结果或者错误。这点与它的名字 race
(赛跑)相契合。
1 | Promise.race([ |
Promisify
一些第三方库的遗留代码仍然使用基于回调的方式处理异步,而 Promise 更加方便好用,所以需要一种方式,将接受回调函数作为参数的函数转化为返回值为 Promise 实例的函数。
一个典型的例子是 setTimeout()
函数:
1 | setTimeout(() => alert('At least 3 seconds passed'), 3000) |
在之前的一篇有关错误处理的博文中讲到,try...catch
结构无法捕获 setTimeout()
函数内的错误,而这一点正是基于回调的 setTimeout()
函数备受指责的原因。
1 | try { |
让我们使用 Promise 封装它:
1 | const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) |
一个更加具体的加载脚本的例子:
1 | // 使用 Promise 封装基于回调处理异步的代码 |
鉴于这是一个常见的需求,可以将其抽象成一个单独的函数 promisify
:
1 | const promisify = function(f) { |
上面的代码存在一个不足,只能接受形式为 (err, result) => {...}
的回调函数,即回调只能接受两个参数,一个代表错误,另一个代表结果。如果遇到类似(err, result1, result2, ...) => {...}
这样的回调函数则无法正常运行。针对这一问题的改进如下:
1 | // 回调 |